Go 遍历切片或数组有两种方式,一种是下标,一种是 range。二者在功能上没有区别,但在性能上会有区别吗?

1.[]int

首先看一下遍历基本类型切片时二者的性能差别,以 []int 为例。

// genRandomIntSlice 生成指定长度的随机 []int 切片
func genRandomIntSlice(n int) []int {
    rand.Seed(time.Now().UnixNano())
    nums := make([]int, 0, n)
    for i := 0; i < n; i++ {
        nums = append(nums, rand.Int())
    }
    return nums
}

func BenchmarkIndexIntSlice(b *testing.B) {
    nums := genRandomIntSlice(1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for k := 0; k < len(nums); k++ {
            tmp = nums[k]
        }
        _ = tmp
    }
}

func BenchmarkRangeIntSlice(b *testing.B) {
    nums := genRandomIntSlice(1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for _, num := range nums {
            tmp = num
        }
        _ = tmp
    }
}

运行测试结果如下:

go test -bench=IntSlice$ .
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkIndexIntSlice-8         5043324               236.2 ns/op
BenchmarkRangeIntSlice-8         5076255               239.1 ns/op

genRandomIntSlice() 函数用于生成指定长度元素类型为 int 的切片。从最终的结果可以看到,遍历 []int 类型的切片,下标与 range 遍历性能几乎没有区别。

2.[]struct{}

那么对于稍微复杂一点的 []struct 类型呢?

type Item struct {
    id  int
    val [1024]byte
}

func BenchmarkIndexStructSlice(b *testing.B) {
    items := make([]Item, 1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for j := 0; j < len(items); j++ {
            tmp = items[j].id
        }
        _ = tmp
    }
}

func BenchmarkRangeIndexStructSlice(b *testing.B) {
    items := make([]Item, 1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for k := range items {
            tmp = items[k].id
        }
        _ = tmp
    }
}

func BenchmarkRangeStructSlice(b *testing.B) {
    items := make([]Item, 1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for _, item := range items {
            tmp = item.id
        }
        _ = tmp
    }
}

运行测试结果如下:

go test -bench=. -gcflags=-N -benchmem main/range
goos: darwin
goarch: amd64
pkg: main/range
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexStructSlice-12              556436              2165 ns/op               1 B/op          0 allocs/op
BenchmarkRangeIndexStructSlice-12         535705              2124 ns/op               1 B/op          0 allocs/op
BenchmarkRangeStructSlice-12               38799             30914 ns/op              27 B/op          0 allocs/op
PASS
ok      main/range      5.097s

可以看出,两种通过 index 遍历 []struct 性能没有差别,但是 range 遍历 []struct 时,性能非常差。

range 只遍历 []struct 下标时,性能比 range 遍历 []struct 值好很多。从这里我们应该能够知道二者性能差别之大的原因。

Item 是一个结构体类型 ,Item 由两个字段构成,一个类型是 int,一个是类型是 [1024]byte,如果每次遍历 []Item,都会进行一次值拷贝,所以带来了性能损耗。

此外,因 range 时获取的是值拷贝,对副本的修改,是不会影响到原切片。

需要注意的时,上面运行基准测试时,使用编译选项-gcflags=-N禁用了编译器对切片遍历的优化。如果没有该选项,那么上面三种遍历方式没有性能差别。

为什么编译器会对上面的测试代码进行优化呢?因为代码实际上只取最后一个切片元素的值,所以前面的循环操作可以跳过,这样便带来性能的提升。如果是下面的代码,那么编器将无法优化,必须遍历拷贝所有元素。

func BenchmarkRange1(b *testing.B) {
    items := make([]Item, 1024)
    tmps := make([]int, 1024)
    for i := 0; i < b.N; i++ {
        for j := range items {
            tmps[j] = items[j].id
        }
    }
}

func BenchmarkRange2(b *testing.B) {
    items := make([]Item, 1024)
    tmps := make([]int, 1024)
    for i := 0; i < b.N; i++ {
        for j, item := range items {
            tmps[j] = item.id
        }
    }
}

无需去除编译器优化,基准测试结果如下:

go test -bench=BenchmarkRange -benchmem  main/range
goos: darwin
goarch: amd64
pkg: main/range
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkRange1-12       2290372               534.8 ns/op             0 B/op          0 allocs/op
BenchmarkRange2-12         46161             27169 ns/op              22 B/op          0 allocs/op
PASS
ok      main/range      3.378s

3.[]*struct

如果切片中是指向结构体的指针,而不是结构体呢?

// genItems 生成指定长度 []*Item 切片
func genItems(n int) []*Item {
    items := make([]*Item, 0, n)
    for i := 0; i < n; i++ {
        items = append(items, &Item{id: i})
    }
    return items
}

func BenchmarkIndexPointer(b *testing.B) {
    items := genItems(1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for k := 0; k < len(items); k++ {
            tmp = items[k].id
        }
        _ = tmp
    }
}

func BenchmarkRangePointer(b *testing.B) {
    items := genItems(1024)
    for i := 0; i < b.N; i++ {
        var tmp int
        for _, item := range items {
            tmp = item.id
        }
        _ = tmp
    }
}

执行性能测试结果:

go test -bench=Pointer$ main/perf
goos: windows
goarch: amd64
pkg: main/perf
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkIndexPointer-8           773634              1521 ns/op
BenchmarkRangePointer-8           752077              1514 ns/op

切片元素从结构体 Item 替换为指针 *Item 后,for 和 range 的性能几乎是一样的。而且使用指针还有另一个好处,可以直接修改指针对应的结构体的值。

4.小结

range 在迭代过程中返回的是元素的拷贝,index 则不存在拷贝。

如果 range 迭代的元素较小,那么 index 和 range 的性能几乎一样,如基本类型的切片 []int。但如果迭代的元素较大,如一个包含很多属性的 struct 结构体,那么 index 的性能将显著地高于 range,有时候甚至会有上千倍的性能差异。对于这种场景,建议使用 index。如果使用 range,建议只迭代下标,通过下标访问元素,这种使用方式和 index 就没有区别了。如果想使用 range 同时迭代下标和值,则需要将切片/数组的元素改为指针,才能不影响性能。

powered by Gitbook该文章修订时间: 2024-03-22 15:30:00

results matching ""

    No results matching ""